import { RelationMetadata } from "../metadata/RelationMetadata"
import { ColumnMetadata } from "../metadata/ColumnMetadata"
import { DataSource } from "../data-source/DataSource"
import { ObjectLiteral } from "../common/ObjectLiteral"
import { SelectQueryBuilder } from "./SelectQueryBuilder"
import { DriverUtils } from "../driver/DriverUtils"
import { QueryRunner } from "../query-runner/QueryRunner"

/**
 * Loads relation ids for the given entities.
 */
export class RelationIdLoader {
    // -------------------------------------------------------------------------
    // Constructor
    // -------------------------------------------------------------------------

    constructor(
        private connection: DataSource,
        protected queryRunner?: QueryRunner | undefined,
    ) {}

    // -------------------------------------------------------------------------
    // Public Methods
    // -------------------------------------------------------------------------

    /**
     * Loads relation ids of the given entity or entities.
     */
    load(
        relation: RelationMetadata,
        entityOrEntities: ObjectLiteral | ObjectLiteral[],
        relatedEntityOrRelatedEntities?: ObjectLiteral | ObjectLiteral[],
    ): Promise<any[]> {
        const entities = Array.isArray(entityOrEntities)
            ? entityOrEntities
            : [entityOrEntities]
        const relatedEntities = Array.isArray(relatedEntityOrRelatedEntities)
            ? relatedEntityOrRelatedEntities
            : relatedEntityOrRelatedEntities
            ? [relatedEntityOrRelatedEntities]
            : undefined

        // load relation ids depend of relation type
        if (relation.isManyToMany) {
            return this.loadForManyToMany(relation, entities, relatedEntities)
        } else if (relation.isManyToOne || relation.isOneToOneOwner) {
            return this.loadForManyToOneAndOneToOneOwner(
                relation,
                entities,
                relatedEntities,
            )
        } else {
            // if (relation.isOneToMany || relation.isOneToOneNotOwner) {
            return this.loadForOneToManyAndOneToOneNotOwner(
                relation,
                entities,
                relatedEntities,
            )
        }
    }

    /**
     * Loads relation ids of the given entities and groups them into the object with parent and children.
     *
     * todo: extract this method?
     */
    async loadManyToManyRelationIdsAndGroup<
        E1 extends ObjectLiteral,
        E2 extends ObjectLiteral,
    >(
        relation: RelationMetadata,
        entitiesOrEntities: E1 | E1[],
        relatedEntityOrEntities?: E2 | E2[],
        queryBuilder?: SelectQueryBuilder<any>,
    ): Promise<{ entity: E1; related?: E2 | E2[] }[]> {
        // console.log("relation:", relation.propertyName);
        // console.log("entitiesOrEntities", entitiesOrEntities);
        const isMany = relation.isManyToMany || relation.isOneToMany
        const entities: E1[] = Array.isArray(entitiesOrEntities)
            ? entitiesOrEntities
            : [entitiesOrEntities]

        if (!relatedEntityOrEntities) {
            relatedEntityOrEntities = await this.connection.relationLoader.load(
                relation,
                entitiesOrEntities,
                this.queryRunner,
                queryBuilder,
            )
            if (!relatedEntityOrEntities.length)
                return entities.map((entity) => ({
                    entity: entity,
                    related: isMany ? [] : undefined,
                }))
        }
        // const relationIds = await this.load(relation, relatedEntityOrEntities!, entitiesOrEntities);
        const relationIds = await this.load(
            relation,
            entitiesOrEntities,
            relatedEntityOrEntities,
        )
        // console.log("entities", entities);
        // console.log("relatedEntityOrEntities", relatedEntityOrEntities);
        // console.log("relationIds", relationIds);

        const relatedEntities: E2[] = Array.isArray(relatedEntityOrEntities)
            ? relatedEntityOrEntities
            : [relatedEntityOrEntities!]

        let columns: ColumnMetadata[] = [],
            inverseColumns: ColumnMetadata[] = []
        if (relation.isManyToManyOwner) {
            columns = relation.junctionEntityMetadata!.inverseColumns.map(
                (column) => column.referencedColumn!,
            )
            inverseColumns = relation.junctionEntityMetadata!.ownerColumns.map(
                (column) => column.referencedColumn!,
            )
        } else if (relation.isManyToManyNotOwner) {
            columns = relation.junctionEntityMetadata!.ownerColumns.map(
                (column) => column.referencedColumn!,
            )
            inverseColumns =
                relation.junctionEntityMetadata!.inverseColumns.map(
                    (column) => column.referencedColumn!,
                )
        } else if (relation.isManyToOne || relation.isOneToOneOwner) {
            columns = relation.joinColumns.map(
                (column) => column.referencedColumn!,
            )
            inverseColumns = relation.entityMetadata.primaryColumns
        } else if (relation.isOneToMany || relation.isOneToOneNotOwner) {
            columns = relation.inverseRelation!.entityMetadata.primaryColumns
            inverseColumns = relation.inverseRelation!.joinColumns.map(
                (column) => column.referencedColumn!,
            )
        } else {
        }

        return entities.map((entity) => {
            const group: { entity: E1; related?: E2 | E2[] } = {
                entity: entity,
                related: isMany ? [] : undefined,
            }

            const entityRelationIds = relationIds.filter((relationId) => {
                return inverseColumns.every((column) => {
                    return column.compareEntityValue(
                        entity,
                        relationId[
                            column.entityMetadata.name +
                                "_" +
                                column.propertyAliasName
                        ],
                    )
                })
            })
            if (!entityRelationIds.length) return group

            relatedEntities.forEach((relatedEntity) => {
                entityRelationIds.forEach((relationId) => {
                    const relatedEntityMatched = columns.every((column) => {
                        return column.compareEntityValue(
                            relatedEntity,
                            relationId[
                                DriverUtils.buildAlias(
                                    this.connection.driver,
                                    undefined,
                                    column.entityMetadata.name +
                                        "_" +
                                        relation.propertyPath.replace(
                                            ".",
                                            "_",
                                        ) +
                                        "_" +
                                        column.propertyPath.replace(".", "_"),
                                )
                            ],
                        )
                    })
                    if (relatedEntityMatched) {
                        if (isMany) {
                            ;(group.related as E2[]).push(relatedEntity)
                        } else {
                            group.related = relatedEntity
                        }
                    }
                })
            })
            return group
        })
    }

    /**
     * Loads relation ids of the given entities and maps them into the given entity property.
     async loadManyToManyRelationIdsAndMap(
     relation: RelationMetadata,
     entityOrEntities: ObjectLiteral|ObjectLiteral[],
     mapToEntityOrEntities: ObjectLiteral|ObjectLiteral[],
     propertyName: string
     ): Promise<void> {
        const relationIds = await this.loadManyToManyRelationIds(relation, entityOrEntities, mapToEntityOrEntities);
        const mapToEntities = mapToEntityOrEntities instanceof Array ? mapToEntityOrEntities : [mapToEntityOrEntities];
        const junctionMetadata = relation.junctionEntityMetadata!;
        const mainAlias = junctionMetadata.name;
        const columns = relation.isOwning ? junctionMetadata.inverseColumns : junctionMetadata.ownerColumns;
        const inverseColumns = relation.isOwning ? junctionMetadata.ownerColumns : junctionMetadata.inverseColumns;
        mapToEntities.forEach(mapToEntity => {
            mapToEntity[propertyName] = [];
            relationIds.forEach(relationId => {
                const match = inverseColumns.every(column => {
                    return column.referencedColumn!.getEntityValue(mapToEntity) === relationId[mainAlias + "_" + column.propertyName];
                });
                if (match) {
                    if (columns.length === 1) {
                        mapToEntity[propertyName].push(relationId[mainAlias + "_" + columns[0].propertyName]);
                    } else {
                        const value = {};
                        columns.forEach(column => {
                            column.referencedColumn!.setEntityValue(value, relationId[mainAlias + "_" + column.propertyName]);
                        });
                        mapToEntity[propertyName].push(value);
                    }
                }
            });
        });
    }*/

    // -------------------------------------------------------------------------
    // Protected Methods
    // -------------------------------------------------------------------------

    /**
     * Loads relation ids for the many-to-many relation.
     */
    protected loadForManyToMany(
        relation: RelationMetadata,
        entities: ObjectLiteral[],
        relatedEntities?: ObjectLiteral[],
    ) {
        const junctionMetadata = relation.junctionEntityMetadata!
        const mainAlias = junctionMetadata.name
        const columns = relation.isOwning
            ? junctionMetadata.ownerColumns
            : junctionMetadata.inverseColumns
        const inverseColumns = relation.isOwning
            ? junctionMetadata.inverseColumns
            : junctionMetadata.ownerColumns
        const qb = this.connection.createQueryBuilder(this.queryRunner)

        // select all columns from junction table
        columns.forEach((column) => {
            const columnName = DriverUtils.buildAlias(
                this.connection.driver,
                undefined,
                column.referencedColumn!.entityMetadata.name +
                    "_" +
                    column.referencedColumn!.propertyPath.replace(".", "_"),
            )
            qb.addSelect(mainAlias + "." + column.propertyPath, columnName)
        })
        inverseColumns.forEach((column) => {
            const columnName = DriverUtils.buildAlias(
                this.connection.driver,
                undefined,
                column.referencedColumn!.entityMetadata.name +
                    "_" +
                    relation.propertyPath.replace(".", "_") +
                    "_" +
                    column.referencedColumn!.propertyPath.replace(".", "_"),
            )
            qb.addSelect(mainAlias + "." + column.propertyPath, columnName)
        })

        // add conditions for the given entities
        let condition1 = ""
        if (columns.length === 1) {
            const values = entities.map((entity) =>
                columns[0].referencedColumn!.getEntityValue(entity),
            )
            const areAllNumbers = values.every(
                (value) => typeof value === "number",
            )

            if (areAllNumbers) {
                condition1 = `${mainAlias}.${
                    columns[0].propertyPath
                } IN (${values.join(", ")})`
            } else {
                qb.setParameter("values1", values)
                condition1 =
                    mainAlias +
                    "." +
                    columns[0].propertyPath +
                    " IN (:...values1)" // todo: use ANY for postgres
            }
        } else {
            condition1 =
                "(" +
                entities
                    .map((entity, entityIndex) => {
                        return columns
                            .map((column) => {
                                const paramName =
                                    "entity1_" +
                                    entityIndex +
                                    "_" +
                                    column.propertyName
                                qb.setParameter(
                                    paramName,
                                    column.referencedColumn!.getEntityValue(
                                        entity,
                                    ),
                                )
                                return (
                                    mainAlias +
                                    "." +
                                    column.propertyPath +
                                    " = :" +
                                    paramName
                                )
                            })
                            .join(" AND ")
                    })
                    .map((condition) => "(" + condition + ")")
                    .join(" OR ") +
                ")"
        }

        // add conditions for the given inverse entities
        let condition2 = ""
        if (relatedEntities) {
            if (inverseColumns.length === 1) {
                const values = relatedEntities.map((entity) =>
                    inverseColumns[0].referencedColumn!.getEntityValue(entity),
                )
                const areAllNumbers = values.every(
                    (value) => typeof value === "number",
                )

                if (areAllNumbers) {
                    condition2 = `${mainAlias}.${
                        inverseColumns[0].propertyPath
                    } IN (${values.join(", ")})`
                } else {
                    qb.setParameter("values2", values)
                    condition2 =
                        mainAlias +
                        "." +
                        inverseColumns[0].propertyPath +
                        " IN (:...values2)" // todo: use ANY for postgres
                }
            } else {
                condition2 =
                    "(" +
                    relatedEntities
                        .map((entity, entityIndex) => {
                            return inverseColumns
                                .map((column) => {
                                    const paramName =
                                        "entity2_" +
                                        entityIndex +
                                        "_" +
                                        column.propertyName
                                    qb.setParameter(
                                        paramName,
                                        column.referencedColumn!.getEntityValue(
                                            entity,
                                        ),
                                    )
                                    return (
                                        mainAlias +
                                        "." +
                                        column.propertyPath +
                                        " = :" +
                                        paramName
                                    )
                                })
                                .join(" AND ")
                        })
                        .map((condition) => "(" + condition + ")")
                        .join(" OR ") +
                    ")"
            }
        }

        // qb.from(junctionMetadata.target, mainAlias)
        //     .where(condition1 + (condition2 ? " AND " + condition2 : ""));
        //
        // // execute query
        // const { values1, values2 } = qb.getParameters();
        // console.log(`I can do it`, { values1, values2 });
        // if (inverseColumns.length === 1 &&
        //     columns.length === 1 &&
        //     this.connection.driver instanceof SqliteDriver &&
        //     (values1.length + values2.length) > 500 &&
        //     values1.length === values2.length) {
        //     console.log(`I can do it`);
        //     return qb.getRawMany();
        //
        // } else {
        //     return qb.getRawMany();
        // }

        // execute query
        const condition = [condition1, condition2]
            .filter((v) => v.length > 0)
            .join(" AND ")
        return qb
            .from(junctionMetadata.target, mainAlias)
            .where(condition)
            .getRawMany()
    }

    /**
     * Loads relation ids for the many-to-one and one-to-one owner relations.
     */
    protected loadForManyToOneAndOneToOneOwner(
        relation: RelationMetadata,
        entities: ObjectLiteral[],
        relatedEntities?: ObjectLiteral[],
    ) {
        const mainAlias = relation.entityMetadata.targetName

        // console.log("entitiesx", entities);
        // console.log("relatedEntitiesx", relatedEntities);
        const hasAllJoinColumnsInEntity = relation.joinColumns.every(
            (joinColumn) => {
                return !!relation.entityMetadata.nonVirtualColumns.find(
                    (column) => column === joinColumn,
                )
            },
        )
        if (relatedEntities && hasAllJoinColumnsInEntity) {
            const relationIdMaps: ObjectLiteral[] = []
            entities.forEach((entity) => {
                const relationIdMap: ObjectLiteral = {}
                relation.entityMetadata.primaryColumns.forEach(
                    (primaryColumn) => {
                        const key =
                            primaryColumn.entityMetadata.name +
                            "_" +
                            primaryColumn.propertyPath.replace(".", "_")
                        relationIdMap[key] =
                            primaryColumn.getEntityValue(entity)
                    },
                )

                relatedEntities.forEach((relatedEntity) => {
                    relation.joinColumns.forEach((joinColumn) => {
                        const entityColumnValue =
                            joinColumn.getEntityValue(entity)
                        const relatedEntityColumnValue =
                            joinColumn.referencedColumn!.getEntityValue(
                                relatedEntity,
                            )
                        if (
                            entityColumnValue === undefined ||
                            relatedEntityColumnValue === undefined
                        )
                            return

                        if (entityColumnValue === relatedEntityColumnValue) {
                            const key =
                                joinColumn.referencedColumn!.entityMetadata
                                    .name +
                                "_" +
                                relation.propertyPath.replace(".", "_") +
                                "_" +
                                joinColumn.referencedColumn!.propertyPath.replace(
                                    ".",
                                    "_",
                                )
                            relationIdMap[key] = relatedEntityColumnValue
                        }
                    })
                })
                if (
                    Object.keys(relationIdMap).length ===
                    relation.entityMetadata.primaryColumns.length +
                        relation.joinColumns.length
                ) {
                    relationIdMaps.push(relationIdMap)
                }
            })
            // console.log("relationIdMap", relationIdMaps);
            // console.log("entities.length", entities.length);
            if (relationIdMaps.length === entities.length)
                return Promise.resolve(relationIdMaps)
        }

        // select all columns we need
        const qb = this.connection.createQueryBuilder(this.queryRunner)
        relation.entityMetadata.primaryColumns.forEach((primaryColumn) => {
            const columnName = DriverUtils.buildAlias(
                this.connection.driver,
                undefined,
                primaryColumn.entityMetadata.name +
                    "_" +
                    primaryColumn.propertyPath.replace(".", "_"),
            )
            qb.addSelect(
                mainAlias + "." + primaryColumn.propertyPath,
                columnName,
            )
        })
        relation.joinColumns.forEach((column) => {
            const columnName = DriverUtils.buildAlias(
                this.connection.driver,
                undefined,
                column.referencedColumn!.entityMetadata.name +
                    "_" +
                    relation.propertyPath.replace(".", "_") +
                    "_" +
                    column.referencedColumn!.propertyPath.replace(".", "_"),
            )
            qb.addSelect(mainAlias + "." + column.propertyPath, columnName)
        })

        // add condition for entities
        let condition: string = ""
        if (relation.entityMetadata.primaryColumns.length === 1) {
            const values = entities.map((entity) =>
                relation.entityMetadata.primaryColumns[0].getEntityValue(
                    entity,
                ),
            )
            const areAllNumbers = values.every(
                (value) => typeof value === "number",
            )

            if (areAllNumbers) {
                condition = `${mainAlias}.${
                    relation.entityMetadata.primaryColumns[0].propertyPath
                } IN (${values.join(", ")})`
            } else {
                qb.setParameter("values", values)
                condition =
                    mainAlias +
                    "." +
                    relation.entityMetadata.primaryColumns[0].propertyPath +
                    " IN (:...values)" // todo: use ANY for postgres
            }
        } else {
            condition = entities
                .map((entity, entityIndex) => {
                    return relation.entityMetadata.primaryColumns
                        .map((column, columnIndex) => {
                            const paramName =
                                "entity" + entityIndex + "_" + columnIndex
                            qb.setParameter(
                                paramName,
                                column.getEntityValue(entity),
                            )
                            return (
                                mainAlias +
                                "." +
                                column.propertyPath +
                                " = :" +
                                paramName
                            )
                        })
                        .join(" AND ")
                })
                .map((condition) => "(" + condition + ")")
                .join(" OR ")
        }

        // execute query
        return qb
            .from(relation.entityMetadata.target, mainAlias)
            .where(condition)
            .getRawMany()
    }

    /**
     * Loads relation ids for the one-to-many and one-to-one not owner relations.
     */
    protected loadForOneToManyAndOneToOneNotOwner(
        relation: RelationMetadata,
        entities: ObjectLiteral[],
        relatedEntities?: ObjectLiteral[],
    ) {
        const originalRelation = relation
        relation = relation.inverseRelation!

        if (
            relation.entityMetadata.primaryColumns.length ===
            relation.joinColumns.length
        ) {
            const sameReferencedColumns =
                relation.entityMetadata.primaryColumns.every((column) => {
                    return relation.joinColumns.indexOf(column) !== -1
                })
            if (sameReferencedColumns) {
                return Promise.resolve(
                    entities.map((entity) => {
                        const result: ObjectLiteral = {}
                        relation.joinColumns.forEach(function (joinColumn) {
                            const value =
                                joinColumn.referencedColumn!.getEntityValue(
                                    entity,
                                )
                            const joinColumnName =
                                joinColumn.referencedColumn!.entityMetadata
                                    .name +
                                "_" +
                                joinColumn.referencedColumn!.propertyPath.replace(
                                    ".",
                                    "_",
                                )
                            const primaryColumnName =
                                joinColumn.entityMetadata.name +
                                "_" +
                                originalRelation.propertyPath.replace(
                                    ".",
                                    "_",
                                ) +
                                "_" +
                                joinColumn.propertyPath.replace(".", "_")
                            result[joinColumnName] = value
                            result[primaryColumnName] = value
                        })
                        return result
                    }),
                )
            }
        }

        const mainAlias = relation.entityMetadata.targetName

        // select all columns we need
        const qb = this.connection.createQueryBuilder(this.queryRunner)
        relation.entityMetadata.primaryColumns.forEach((primaryColumn) => {
            const columnName = DriverUtils.buildAlias(
                this.connection.driver,
                undefined,
                primaryColumn.entityMetadata.name +
                    "_" +
                    originalRelation.propertyPath.replace(".", "_") +
                    "_" +
                    primaryColumn.propertyPath.replace(".", "_"),
            )
            qb.addSelect(
                mainAlias + "." + primaryColumn.propertyPath,
                columnName,
            )
        })
        relation.joinColumns.forEach((column) => {
            const columnName = DriverUtils.buildAlias(
                this.connection.driver,
                undefined,
                column.referencedColumn!.entityMetadata.name +
                    "_" +
                    column.referencedColumn!.propertyPath.replace(".", "_"),
            )
            qb.addSelect(mainAlias + "." + column.propertyPath, columnName)
        })

        // add condition for entities
        let condition: string = ""
        if (relation.joinColumns.length === 1) {
            const values = entities.map((entity) =>
                relation.joinColumns[0].referencedColumn!.getEntityValue(
                    entity,
                ),
            )
            const areAllNumbers = values.every(
                (value) => typeof value === "number",
            )

            if (areAllNumbers) {
                condition = `${mainAlias}.${
                    relation.joinColumns[0].propertyPath
                } IN (${values.join(", ")})`
            } else {
                qb.setParameter("values", values)
                condition =
                    mainAlias +
                    "." +
                    relation.joinColumns[0].propertyPath +
                    " IN (:...values)" // todo: use ANY for postgres
            }
        } else {
            condition = entities
                .map((entity, entityIndex) => {
                    return relation.joinColumns
                        .map((joinColumn, joinColumnIndex) => {
                            const paramName =
                                "entity" + entityIndex + "_" + joinColumnIndex
                            qb.setParameter(
                                paramName,
                                joinColumn.referencedColumn!.getEntityValue(
                                    entity,
                                ),
                            )
                            return (
                                mainAlias +
                                "." +
                                joinColumn.propertyPath +
                                " = :" +
                                paramName
                            )
                        })
                        .join(" AND ")
                })
                .map((condition) => "(" + condition + ")")
                .join(" OR ")
        }

        // execute query
        return qb
            .from(relation.entityMetadata.target, mainAlias)
            .where(condition)
            .getRawMany()
    }
}
